import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { getSession } from "@/lib/auth/session"; import { canAccessBranch } from "@/lib/auth/permissions"; import { withErrorHandling, badRequest, unauthorized, forbidden, notFound, ApiError, } from "@/lib/api/errors"; import { mapStorageReadError } from "@/lib/api/storageErrors"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const BRANCH_RE = /^NL\d+$/; const YEAR_RE = /^\d{4}$/; const MONTH_RE = /^(0[1-9]|1[0-2])$/; const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/; function getNasRootOrThrow() { const root = process.env.NAS_ROOT_PATH; if (!root) { throw new ApiError({ status: 500, code: "FS_STORAGE_ERROR", message: "Internal server error", }); } return root; } function isSafeFilename(name) { if (typeof name !== "string") return false; const trimmed = name.trim(); if (!trimmed) return false; // Reject special path segments if (trimmed === "." || trimmed === "..") return false; // Reject any path separators (defense-in-depth) if (trimmed.includes("/") || trimmed.includes("\\")) return false; // Reject control chars (header injection) if (/[\r\n\t]/.test(trimmed)) return false; // Reject quotes to keep Content-Disposition predictable/safe if (trimmed.includes('"')) return false; // Ensure it's a basename (no sneaky segments) if (path.basename(trimmed) !== trimmed) return false; return true; } function isPdfFilename(name) { return typeof name === "string" && name.toLowerCase().endsWith(".pdf"); } function validateParamsOrThrow({ branch, year, month, day, filename }) { if (!BRANCH_RE.test(branch)) { throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", { branch, }); } if (!YEAR_RE.test(year)) { throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year }); } if (!MONTH_RE.test(month)) { throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month }); } if (!DAY_RE.test(day)) { throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day }); } if (!isSafeFilename(filename)) { throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", { filename, }); } if (!isPdfFilename(filename)) { throw badRequest( "VALIDATION_FILE_EXTENSION", "Only PDF files are allowed", { filename } ); } } function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) { const rootAbs = path.resolve(root); const absPath = path.resolve(rootAbs, branch, year, month, day, filename); // Ensure the resolved path stays within NAS_ROOT_PATH const rel = path.relative(rootAbs, absPath); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", { branch, year, month, day, filename, }); } return absPath; } /** * GET /api/files/:branch/:year/:month/:day/:filename * * Query (optional): * - download=1 | download=true => Content-Disposition: attachment * - default => inline */ export const GET = withErrorHandling( async function GET(request, ctx) { const session = await getSession(); if (!session) { throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized"); } const { branch, year, month, day, filename } = await ctx.params; const missing = []; if (!branch) missing.push("branch"); if (!year) missing.push("year"); if (!month) missing.push("month"); if (!day) missing.push("day"); if (!filename) missing.push("filename"); if (missing.length > 0) { throw badRequest( "VALIDATION_MISSING_PARAM", "Missing required route parameter(s)", { params: missing } ); } if (!canAccessBranch(session, branch)) { throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden"); } validateParamsOrThrow({ branch, year, month, day, filename }); const root = getNasRootOrThrow(); const absPath = resolvePdfPathOrThrow({ root, branch, year, month, day, filename, }); const details = { branch, year, month, day, filename }; let stat; try { stat = await fsp.stat(absPath); } catch (err) { throw await mapStorageReadError(err, { details }); } if (!stat.isFile()) { throw notFound("FS_NOT_FOUND", "Not found", details); } const { searchParams } = new URL(request.url); const download = (searchParams.get("download") || "").toLowerCase(); const asAttachment = download === "1" || download === "true"; const dispositionType = asAttachment ? "attachment" : "inline"; const contentDisposition = `${dispositionType}; filename="${filename}"`; const nodeStream = fs.createReadStream(absPath); const webStream = Readable.toWeb(nodeStream); return new Response(webStream, { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": contentDisposition, "Content-Length": String(stat.size), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }, }); }, { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" } );